42 — 摇摆球体 Shader

Three.js Journey · Bruno Simon · 难度:简单

42 — 摇摆球体 Shader 课程海报

简介

在本课中,我们将创建一个摇摆的球体:

lesson demo video preview

为了创建如此有机自然的动画,我们需要在 vertex shader 中对顶点进行动画处理,但如你所见,MeshPhysicalMaterial 的所有属性仍然受到支持。

我们可以调整 metalness、roughness、transmission 和 IOR。甚至连 shadow 都受到支持,而且看起来仍然就像经典的 MeshPhysicalMaterial。

为了实现这一点,我们需要像在 Modified Material 一课中所做的那样,从内置的 MeshPhysicalMaterial 出发并改进它。但这一次,我们将采用不同的方法,依赖 Custom Shader Material 库。

这个库会将 shader 代码直接注入到 Three.js 内置 material 中,而我们无需深入研究 Three.js 的 shader 就能理解在哪里以及如何注入代码,因此对初学者非常友好。

设置

起始项目已经包含以下内容:

Custom Shader Material

为了实现这个效果,我们将增强 MeshPhysicalMaterial 并让表面产生摇摆。

目前,我们只想实现 Custom Shader Material,稍后我们将创建摇摆动画。

依赖

Custom Shader Material 可在 NPM 上获取:https://www.npmjs.com/package/three-custom-shader-material

使用 npm install 安装该库,并将版本强制指定为 5.4:

npm install three-custom-shader-material@5.4

我们强制指定版本是为了确保它能与本节课一起正常工作。

在 script.js 中,从 'three-custom-shader-material/vanilla' 导入 CustomShaderMaterial:

import CustomShaderMaterial from 'three-custom-shader-material/vanilla'

vanilla 表示这是该库的经典实现,不依赖任何第三方库,例如 React Three Fiber(你将在课程的后续部分了解它)。

使用 CustomShaderMaterial,我们可以基于内置 material 创建新的 material。

将 THREE.MeshPhysicalMaterial 替换为 CustomShaderMaterial,并为 THREE.MeshPhysicalMaterial 添加 baseMaterial 属性。接下来,我们将用注释把 Custom Shader Material 和基础 material 的属性区分开:

// Material
const material = new CustomShaderMaterial({
    // CSM
    baseMaterial: THREE.MeshPhysicalMaterial,

    // MeshPhysicalMaterial
    metalness: 0,
    roughness: 0.5,
    color: '#ffffff',
    transmission: 0,
    ior: 1.5,
    thickness: 1.5,
    transparent: true,
    wireframe: false
})
lesson demo video preview

如你所见,这个 material 仍然正常工作。尽管它现在是 CustomShaderMaterial 的实例,但它使用 THREE.MeshPhysicalMaterial 作为基础 material,之前支持的所有属性仍然有效。

注入自定义 shader

现在我们想注入自己的 shader 代码,这正是 Custom Shader Material 派上用场的地方。借助这个库,我们添加自定义 shader 就像编写普通的 vertex shader 和 fragment shader 一样。

在 src/shaders/ 中,创建一个 wobble/ 文件夹。

在其中创建一个包含空 main() 的 vertex.glsl 文件:

void main()
{
    
}

仍在 src/shaders/wobble/ 中,创建一个具有相同空 main() 的 fragment.glsl:

void main()
{
    
}

在 script.js 中,将它们分别导入为 wobbleVertexShader 和 wobbleFragmentShader:

import wobbleVertexShader from './shaders/wobble/vertex.glsl'
import wobbleFragmentShader from './shaders/wobble/fragment.glsl'

使用 vertexShader 和 fragmentShader 属性将它们添加到 CustomShaderMaterial,类似我们对 ShaderMaterial 所做的那样:

// Material
const material = new CustomShaderMaterial({
    // CSM
    baseMaterial: THREE.MeshPhysicalMaterial,
    vertexShader: wobbleVertexShader,
    fragmentShader: wobbleFragmentShader,
    
    // MeshPhysicalMaterial
    // ...
})

什么都没有发生,而这是好消息。

我们已经注入了自己的 vertex shader 和 fragment shader,但目前它们什么都不做。

警告

你可能已经注意到控制台中出现了一条警告:

这是 Custom Shader Material 在提醒我们存在一些已被处理且无害的意外代码片段。

你可以通过将 silent 属性设为 true 来消除这些警告:

// Material
const material = new CustomShaderMaterial({
    // CSM
    baseMaterial: THREE.MeshPhysicalMaterial,
    vertexShader: wobbleVertexShader,
    fragmentShader: wobbleFragmentShader,
    silent: true,
    
    // MeshPhysicalMaterial
    // ...
})

变量

现在我们要了解如何更新内置 material。

为此,我们可以使用 Custom Shader Material 变量,你可以在文档中找到它们:https://github.com/FarazzShaikh/THREE-CustomShaderMaterial?tab=readme-ov-file#output-variables

Custom Shader Material 创建了一系列变量,例如 csm_Position、csm_Normal、csm_PointSize、csm_DiffuseColor、csm_Roughness,修改这些变量将改变 shader 的最终输出。

让我们做几个测试。

在 vertex.glsl 的 main() 函数中,增大 csm_Position.y:

void main()
{
    csm_Position.y += 2.0;
}

球体高了 2 个单位。

我们甚至可以利用当前位置创建有趣的形状:

void main()
{
    csm_Position.y += sin(csm_Position.z * 3.0) * 0.5;
}

那 fragment.glsl 呢?

我们可以使用 csm_FragColor 更改最终颜色。将 csm_FragColor 的 rgb 改为亮红色:

void main()
{
    csm_FragColor.rgb = vec3(1.0, 0.5, 0.5);
}

不错,但我们失去了明暗着色。

好消息是:我们可以使用 csm_DiffuseColor 调整 material 的颜色(在应用 light 和 shadow 之前)。

将 csm_DiffuseColor 的 rgb 改为纯红色:

void main()
{
    csm_DiffuseColor.rgb = vec3(1.0, 0.0, 0.0);
}

这就像更改 color 属性一样。虽然没什么实际用处,但至少我们现在知道它是如何工作的了。

让我们玩得更有趣一点。我们将通过以垂直条纹图案更新 csm_Metalness 和 csm_Roughness 来调整 metalness 和 roughness。跟着代码走,你就会明白。

首先,我们需要在 fragment shader 中获取 UV 坐标。

就像我们以往所做的那样,在 vertex.glsl 中创建一个 vec2 vUv varying 并将 uv 赋给它:

varying vec2 vUv;

void main()
{
    // ...

    // Varying
    vUv = uv;
}

在 fragment.glsl 中取回它:

varying vec2 vUv;

void main()
{
    // ...
}

使用 sin 和 step 更新 csm_Metalness,作用于乘以 100.0 后的 vUv.x :

void main()
{
    csm_Metalness = step(0.0, sin(vUv.x * 100.0));
}

通过用 1.0 减去 csm_Metalness 来将 csm_Roughness 设为相反值:

void main()
{
    csm_Metalness = step(0.0, sin(vUv.x * 100.0));
    csm_Roughness = 1.0 - csm_Metalness;
}

如果你不喜欢图案中奇怪的阶梯效果,可以在 sin 中加上 0.5:

void main()
{
    csm_Metalness = step(0.0, sin(vUv.x * 100.0 + 0.5));
    csm_Roughness = 1.0 - csm_Metalness;
}

这就是 Custom Shader Material 的强大之处。

但这并不是我们想要实现的效果。

移除 vUv 并清空两个 main() 函数:

void main()
{
}

基础摇摆

让我们从摇摆效果开始。

原理

这个过程与我们在 Raging Sea Shading 几课中所做的类似。我们根据顶点位置计算出一个高度,然后移动该顶点。

对于 Raging Sea,我们可以利用 y 轴让顶点上下移动。但在本例中,事情没那么简单。我们想让顶点根据面的朝向前后移动。

好消息是,我们已经知道有一个变量可以指示面的方向,那就是 normal:

实现

我们将使用现在已经熟悉的 Simplex Noise,它已经以 simplexNoise4d 的形式存在于 src/shaders/includes/ 文件夹中。

它是一个 4D Simplex Noise,因此我们可以将 3D 位置作为基础输入,并使用第四个维度让它随时间变化。

在 vertex.glsl 中引入它:

#include ../includes/simplexNoise4d.glsl

void main()
{
}

调用 simplexNoise4d(),传入一个 vec4() 并将其保存为 float wobble(不要保存):

void main()
{
    // Wobble
    float wobble = simplexNoise4d(vec4());
}

对于这个 vec4,我们将位置作为前三个值传入,我们可以使用 csm_Position 获取该位置。第四个值我们设为 0.0,稍后会用时间替换它:

void main()
{
    // Wobble
    float wobble = simplexNoise4d(vec4(
        csm_Position, // XYZ
        0.0           // W
    ));
}

最后,我们想让顶点根据 normal 移动,这很简单,只需将 wobble 乘以 normal 并加到 csm_Position 上:

void main()
{
    // Wobble
    float wobble = simplexNoise4d(vec4(
        csm_Position, // XYZ
        0.0           // W
    ));
    csm_Position += wobble * normal;
}

计算 normal

看起来好像起作用了,但明暗着色看起来很怪。就好像面的朝向不正确,这正是我们在为 Raging Sea 添加明暗着色时遇到的问题。

我们不能再依赖基础 normal 了,因此需要计算它们并使用相邻顶点技术。

在 Raging Sea 的情况下,我们能够相当容易地计算出相邻顶点在哪里,因为它是一个平面网格:

但这里的情况稍微复杂一些。由于它是一个球体,我们可以尝试用复杂的三角函数来找到相邻顶点,但我们希望摇摆效果即使在导入的模型上也能生效。

幸运的是,对于 3D geometry 而言,指向我们要找的相邻顶点的两个向量是众所周知的。那就是 “tangent” 和 “bitangent”:

目前我们只专注于 tangent,稍后再研究 bitangent。

Tangent

Three.js 可以通过专门为 tangent 创建一个 attribute 来帮助我们,这样我们就能在 vertex shader 中访问它。听起来好得令人难以置信?让我们试一试。

在 script.js 中,实例化 IcosahedronGeometry 之后,用 console.log() 打印 geometry.attributes,看看我们是否已经有了这个 tangent attribute:

const geometry = new THREE.IcosahedronGeometry(2.5, 50)
console.log(geometry.attributes)

我们得到了 normal、position 和 uv,但没有 tangent。

如果你查看 BufferGeometry 文档(IcosahedronGeometry 继承自它),会发现有一个 computeTangents() 方法。

让我们试一试:

const geometry = new THREE.IcosahedronGeometry(2.5, 50)
geometry.computeTangents()
console.log(geometry.attributes)

我们遇到了一个错误。

要计算 tangent,Three.js 需要 geometry 上具有以下 attribute:index、position、normal 和 uv。

我们缺少 index,它指的正是我们在前几课中提到的那种索引。它使得同一个顶点可以被多个三角形重复使用:

这意味着我们需要将非索引 geometry 转换为索引 geometry,得益于一个名为 BufferGeometryUtils 的特殊 Three.js 类,我们可以做到这一点。

这个类提供了一系列函数,我们无需实例化它就能使用这些函数。相反,我们可以只导入我们需要的函数。而将我们的 geometry 转换为索引 geometry 的函数就是 mergeVertices。

从 'three/addons/utils/BufferGeometryUtils.js' 导入 mergeVertices:

import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js'

现在将 geometry 的 const 替换为 let,因为我们想替换这个变量;对 geometry 调用 mergeVertices() 并替换它:

let geometry = new THREE.IcosahedronGeometry(2.5, 50)
geometry = mergeVertices(geometry)
geometry.computeTangents()
console.log(geometry.attributes)

现在我们有了一个全新的 tangent attribute。

要注意,合并顶点以生成索引 geometry 可能非常耗费性能。更糟的是,所需时间会随着顶点数量的增加呈指数级增长。幸运的是,我们只在体验开始时执行一次,所以请留意体验开始时的那次长时间卡顿。

另外请注意,导入的模型可能已经具有索引 geometry,我们稍后将使用的模型就是这种情况。

移除 console.log():

let geometry = new THREE.IcosahedronGeometry(2.5, 50)
geometry = mergeVertices(geometry)
geometry.computeTangents()

Bitangent

我们有 normal,有 tangent,现在还需要 bitangent。

你能猜到如何找到它吗?给你一个提示,bitangent 同时垂直于 normal 和 tangent。

我们将使用叉积(cross product)!

在 vertex.glsl 中,取回 tangent attribute。要注意,它是一个 vec4:

attribute vec4 tangent;

normal attribute 已经注入到 shader 中了。

在 main() 函数的开头,使用 cross 计算 normal 与 tangent.xyz 的叉积,并将其保存为 vec3 biTangent 变量:

void main()
{
    vec3 biTangent = cross(normal, tangent.xyz);

    // ...
}

就这样。现在我们有了 tangent 和 biTangent,可以用它们来计算两个相邻顶点所在的位置。

计算 normal

要计算 normal,首先需要确定两个相邻顶点,我们将它们命名为 A 和 B:

A 在 tangent 一侧,B 在 biTangent 一侧。

首先,让我们将到这些相邻顶点的距离定义为一个 float shift 变量:

void main()
{
    vec3 biTangent = cross(normal, tangent.xyz);

    // Neighbours positions    
    float shift = 0.01;

    // ...
}

现在我们可以基于 csm_Position 创建 positionA 和 positionB,并分别加上乘以 shift 的 tangent 和 biTangent:

void main()
{
    // ...

    // Neighbours positions
    float shift = 0.01;
    vec3 positionA = csm_Position + tangent.xyz * shift;
    vec3 positionB = csm_Position + biTangent * shift;

    // ...
}

由于我们将要应用 simplexNoise4d 三次,不妨把它放到一个单独的函数中。

在 main() 之前,创建一个返回 float 的 getWobble 函数(不要保存):

float getWobble()
{
    
}

void main()
{
    // ...
}

返回同样的 simplexNoise4d(不要保存):

float getWobble()
{
    return simplexNoise4d(vec4(
        csm_Position, // XYZ
        0.0           // W
    ));
}

添加一个 vec3 position 参数并用它替换 csm_Position(可以保存):

float getWobble(vec3 position)
{
    return simplexNoise4d(vec4(
        position, // XYZ
        0.0       // W
    ));
}

将 main() 中的 simplexNoise4d 替换为对 getWobble() 的调用:

void main()
{
    // ...

    // Wobble
    float wobble = getWobble(csm_Position);
    csm_Position += wobble * normal;
}

现在我们可以使用同一个 getWobble() 来更新 positionA 和 positionB:

void main()
{
    // ...

    // Wobble
    float wobble = getWobble(csm_Position);
    csm_Position += wobble * normal;
    positionA    += getWobble(positionA) * normal;
    positionB    += getWobble(positionB) * normal;
}

我们将当前顶点位置表示为 csm_Position,将两个相邻顶点表示为 positionA 和 positionB。现在我们需要从当前顶点指向相邻顶点的方向,我们将它们称为 toA 和 toB。

要计算从一个 vec3 到另一个的方向,我们用起点减去终点。

由于我们只需要方向,我们使用 normalize 对它进行归一化:

void main()
{
    // ...

    // Compute normal
    vec3 toA = normalize(positionA - csm_Position);
    vec3 toB = normalize(positionB - csm_Position);
}

最后,我们可以计算两者的叉积:

对 toA 与 toB 调用 cross,并将其保存为 csm_Normal 以覆盖默认 normal:

void main()
{
    // ...

    // Compute normal
    vec3 toA = normalize(positionA - csm_Position);
    vec3 toB = normalize(positionB - csm_Position);
    csm_Normal = cross(toA, toB);
}

我们得到了正确的 normal。

修复 shadow

在对摇摆效果进行动画和改进之前,我们需要修复 shadow。

你可以看到平面上有问题,mesh 本身上也有问题:

lesson demo video preview

如果你还记得前几课的内容,为了处理 shadow,Three.js 会把从 light 视角看到的 scene 渲染到一张离屏 texture 中,并将所有 material 替换为 MeshDepthMaterial。利用这次渲染,Three.js 判断表面是否处于阴影中。

但摇摆效果并没有应用在默认的 MeshDepthMaterial 上。

我们需要提供自己的支持摇摆的 MeshDepthMaterial,可以借助 Custom Shader Material 增强 MeshDepthMaterial 来实现,然后将其赋值给 Mesh 的 customDepthMaterial 属性。

首先,让我们创建 depth material。

复制 material,将其命名为 depthMaterial,并将其 baseMaterial 设为 THREE.MeshDepthMaterial:

const depthMaterial = new CustomShaderMaterial({
    // CSM
    baseMaterial: THREE.MeshDepthMaterial,
    vertexShader: wobbleVertexShader,
    fragmentShader: wobbleFragmentShader,
    silent: true,

    // ...
})

移除所有与 MeshPhysicalMaterial 相关的属性,并添加一个名为 depthPacking 的属性,设为 THREE.RGBADepthPacking:

const depthMaterial = new CustomShaderMaterial({
    // CSM
    baseMaterial: THREE.MeshDepthMaterial,
    vertexShader: wobbleVertexShader,
    fragmentShader: wobbleFragmentShader,
    silent: true,

    // MeshDepthMaterial
    depthPacking: THREE.RGBADepthPacking
})

depthPacking 是 Three.js 使用的一种算法,它将深度编码到全部 4 个通道中,而不是使用灰度深度,从而提高精度。

由于我们不想更改 fragment shader,移除 fragmentShader 属性:

const depthMaterial = new CustomShaderMaterial({
    // CSM
    baseMaterial: THREE.MeshDepthMaterial,
    vertexShader: wobbleVertexShader,
    silent: true,

    // MeshDepthMaterial
    depthPacking: THREE.RGBADepthPacking
})

如果你好奇 depth material 长什么样,可以把它设置到 Mesh 上:

const wobble = new THREE.Mesh(geometry, depthMaterial)

把 material 放回去:

const wobble = new THREE.Mesh(geometry, material)

将 depthMaterial 赋值给 wobble 的 customDepthMaterial 属性:

const wobble = new THREE.Mesh(geometry, material)
wobble.customDepthMaterial = depthMaterial
lesson demo video preview

我们的 shadow 修好了。

让摇摆动起来并控制它

我们将使用 uniforms 来让摇摆动起来并进行控制。此外,我们将一次性创建 4 个 uniforms:

由于这些 uniforms 会同时影响 material 和 depthMaterial,我们只创建一次并在两个 material 上共用它们。

在 material 之前,使用 Uniform 类创建一个包含以下属性的 uniforms 对象:

// Material
const uniforms = {
    uTime: new THREE.Uniform(0),
    uPositionFrequency: new THREE.Uniform(0.5),
    uTimeFrequency: new THREE.Uniform(0.4),
    uStrength: new THREE.Uniform(0.3),
}

const material = new CustomShaderMaterial({
    // ...
})

const depthMaterial = new CustomShaderMaterial({
    // ...
})

将 uniforms 传给两个 material 的 uniforms 属性:

const material = new CustomShaderMaterial({
    // ...
    uniforms: uniforms,
    // ...
})

const depthMaterial = new CustomShaderMaterial({
    // ...
    uniforms: uniforms,
    // ...
})

为 uPositionFrequency、uTimeFrequency 和 uStrength 添加调节项,min 值为 0,max 为 2,step 为 0.001:

// Tweaks
gui.add(uniforms.uPositionFrequency, 'value', 0, 2, 0.001).name('uPositionFrequency')
gui.add(uniforms.uTimeFrequency, 'value', 0, 2, 0.001).name('uTimeFrequency')
gui.add(uniforms.uStrength, 'value', 0, 2, 0.001).name('uStrength')
// ...

在 tick 函数中,使用 elapsedTime 更新 uTime.value:

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Materials
    uniforms.uTime.value = elapsedTime

    // ...
}

在 vertex.glsl 中,取回全部 4 个 uniforms:

uniform float uTime;
uniform float uPositionFrequency;
uniform float uTimeFrequency;
uniform float uStrength;

现在让我们把它们应用到 simplexNoise4d() 上。

将 position 乘以 uPositionFrequency:

float getWobble(vec3 position)
{
    return simplexNoise4d(vec4(
        position * uPositionFrequency, // XYZ
        0.0                            // W
    ));
}

将整个 simplexNoise4d() 乘以 uStrength:

float getWobble(vec3 position)
{
    return simplexNoise4d(vec4(
        position * uPositionFrequency, // XYZ
        0.0                            // W
    )) * uStrength;
}

将 vec4 的第四个值(当前为 0.0)替换为 uTime 乘以 uTimeFrequency:

float getWobble(vec3 position)
{
    return simplexNoise4d(vec4(
        position * uPositionFrequency, // XYZ
        uTime * uTimeFrequency         // W
    )) * uStrength;
}
lesson demo video preview

扭曲

效果看起来不错,但太规律了。

我们可以将多个不同频率的 Simplex Noise 组合起来,但那样看起来更像是波浪。相反,我们将对发送给 Simplex Noise 的位置进行扭曲。

如果你是 Warhammer 40k 的粉丝,别害怕,这不是那种 warp(亚空间)。

思路是,我们将对发送给 Simplex Noise 的位置再应用一次 Simplex Noise。这是一次 Simplex Noise 的“盗梦空间”。

在 getWobble 函数中,基于 position 创建一个 vec3 warpedPosition,并将它(而不是 position)发送给 simplexNoise4d():

float getWobble(vec3 position)
{
    vec3 warpedPosition = position;

    return simplexNoise4d(vec4(
        warpedPosition * uPositionFrequency, // XYZ
        // ...
    )) * uStrength;
}

现在向 warpedPosition 添加另一个 simplexNoise4d,将 position 作为 vec4 的前 3 个值,uTime 作为第四个值:

float getWobble(vec3 position)
{
    vec3 warpedPosition = position;
    warpedPosition += simplexNoise4d(vec4(
        position,
        uTime
    ));

    // ...
}
lesson demo video preview

这就是我们想要的效果,但我们需要对它进行调节。由于参数与我们在最初的 simplexNoise4d() 中使用的类似,让我们再次添加 uniforms 和相关的调节项。

回到 script.js,向 uniforms 对象添加以下 uWarpPositionFrequency、uWarpTimeFrequency 和 uWarpStrength uniforms:

const uniforms = {
    // ...
    uWarpPositionFrequency: new THREE.Uniform(0.38),
    uWarpTimeFrequency: new THREE.Uniform(0.12),
    uWarpStrength: new THREE.Uniform(1.7),
}

添加相应的调节项,min 值为 0,max 为 2,step 为 0.001:

// Tweaks
// ...
gui.add(uniforms.uWarpPositionFrequency, 'value', 0, 2, 0.001).name('uWarpPositionFrequency')
gui.add(uniforms.uWarpTimeFrequency, 'value', 0, 2, 0.001).name('uWarpTimeFrequency')
gui.add(uniforms.uWarpStrength, 'value', 0, 2, 0.001).name('uWarpStrength')
// ...

在 vertex.glsl 中,取回这三个 uniforms:

uniform float uWarpPositionFrequency;
uniform float uWarpTimeFrequency;
uniform float uWarpStrength;

将它们应用到与 warpedPosition 关联的 simplexNoise4d():

float getWobble(vec3 position)
{
    vec3 warpedPosition = position;
    warpedPosition += simplexNoise4d(
        vec4(
            position * uWarpPositionFrequency,
            uTime * uWarpTimeFrequency
        )
    ) * uWarpStrength;

    // ...
}
lesson demo video preview

最棒的是,我们仍然可以调整 MeshPhysicalMaterial 的属性:

lesson demo video preview

Fragment

摇摆效果已经完成了,但让我们再玩一玩,调整 fragment shader,让颜色根据摇摆发生变化。

颜色混合

由于我们要创建一个随摇摆变化的 fragment,首先需要将它从 vertex 传递到 fragment。

在 vertex.glsl 中,创建一个 float vWobble varying 并将 wobble 赋给它:

varying float vWobble;

// ...

void main()
{
    // ...
    
    // Varying
    vWobble = wobble;
}

在 fragment.glsl 中取回 float vWobble:

varying float vWobble;

出于好奇,我们想看看 vWobble 里面有什么。在经典的 ShaderMaterial 中,我们会将它发送给 gl_FragColor 来查看可视化结果。借助 Custom Shader Material,我们可以用 csm_FragColor 变量来实现。

将 vWobble 组成的 vec3() 赋给 csm_FragColor 的 rgb:

void main()
{
    csm_FragColor.rgb = vec3(vWobble);
}

它起作用了,但对比度不是很高,就好像尽管 Simplex Noise 输出的数值范围是 -1 到 +1,vWobble 却远达不到 1 一样。

原因是我们对 simplexNoise4d() 应用了 uStrength,而目前 uStrength 相当低。

为了解决这个问题,在 vertex.glsl 中,当把 wobble 赋给 vWobble 时用 uStrength 去除 wobble,从而恢复 simplexNoise4d() 的原始范围:

void main()
{
    // ...

    // Varying
    vWobble = wobble / uStrength;
}

现在我们对 vWobble 满意了,在 fragment.glsl 中移除 csm_FragColor:

void main()
{
}

在这里你可以随心所欲地发挥。你可以绘制形状、调整 roughness、调整 metalness、让颜色变化等等。

在我们的例子中,我们将根据摇摆混合两种颜色。首先,我们需要将这两种颜色作为 uniforms 发送,并将它们设为可调节。

在 script.js 中,在 uniforms 之前,向 debugObject 添加 colorA 和 colorB:

debugObject.colorA = '#0000ff'
debugObject.colorB = '#ff0000'

const uniforms = {
    // ...
}

使用 Uniform 类和 Color 类将它们作为 uColorA 和 uColorB 添加到 uniforms 中:

const uniforms = {
    // ...
    uColorA: new THREE.Uniform(new THREE.Color(debugObject.colorA)),
    uColorB: new THREE.Uniform(new THREE.Color(debugObject.colorB))
}

这意味着我们也会把这些颜色 uniforms 发送给 depth material。虽然没用,但也不会产生任何错误,所以为了简化,我们就保持这样。

移除 'color' 调节项,并使用 addColor() 将其替换为 uColorA 和 uColorB 调节项:

// Tweaks
// ...
gui.addColor(debugObject, 'colorA').onChange(() => uniforms.uColorA.value.set(debugObject.colorA))
gui.addColor(debugObject, 'colorB').onChange(() => uniforms.uColorB.value.set(debugObject.colorB))
// ...

在 fragment.glsl 中,将 uColorA 和 uColorB uniforms 作为 vec3 取回:

uniform vec3 uColorA;
uniform vec3 uColorB;

在 main() 中,通过重新映射 vWobble 创建一个 float colorMix 变量,使其范围从 -1 到 +1 变为 0 到 1,并使用 smoothstep 平滑该值:

void main()
{
    float colorMix = smoothstep(- 1.0, 1.0, vWobble);
}

将 colorMix 作为 mix 的系数,在 uColorA 和 uColorB 之间混合。将其保存为 csm_DiffuseColor:

void main()
{
    float colorMix = smoothstep(- 1.0, 1.0, vWobble);
    csm_DiffuseColor.rgb = mix(uColorA, uColorB, colorMix);
}

反光尖端

这很棒,但还可以更好。

还记得我们一开始使用的 metalness/roughness 技巧吗?让我们用同样的技术让摇摆的尖端具有反光效果。

对 vWobble 使用阈值为 0.25 的 step,并将其赋给 csm_Metalness:

void main()
{
    // ...

    // Mirror step
    csm_Metalness = step(0.25, vWobble);
}

通过用 1.0 减去 csm_Metalness 来将 csm_Roughness 设为相反值:

void main()
{
    // ...

    // Mirror step
    csm_Metalness = step(0.25, vWobble);
    csm_Roughness = 1.0 - csm_Metalness;
}
lesson demo video preview

光泽尖端

尽管这个效果不错,但让我们把它注释掉,尝试一些更微妙的效果。

void main()
{
    float colorMix = smoothstep(- 1.0, 1.0, vWobble);
    csm_DiffuseColor.rgb = mix(uColorA, uColorB, colorMix);

    // // Mirror step
    // csm_Metalness = step(0.25, vWobble);
    // csm_Roughness = 1.0 - csm_Metalness;
}

我们想通过降低 roughness 让摇摆的尖端更有光泽。

为此,用 1.0 减去 colorMix,并将其赋给 csm_Roughness:

void main()
{
    // ...

    // Shinny tip
    csm_Roughness = 1.0 - colorMix;
}
lesson demo video preview

它开始变得奇怪了,所以我们就在这里停下。不过,欢迎你自由尝试。

模型

它对球体有效,但对任何形状都有效吗?

常用的 Suzanne 模型以 suzanne.glb 的形式提供在 static/ 文件夹中。

这个模型相当简单。它是对我们从 Blender 得到的默认 Suzanne 进行细分和平滑处理后的版本:

在 script.js 中,注释掉 Geometry 和 Mesh 部分:

// // Geometry
// let geometry = new THREE.IcosahedronGeometry(2.5, 50)
// geometry = mergeVertices(geometry)
// geometry.computeTangents()

// // Mesh
// const wobble = new THREE.Mesh(geometry, material)
// wobble.customDepthMaterial = depthMaterial
// wobble.receiveShadow = true
// wobble.castShadow = true
// scene.add(wobble)

使用 GLTFLoader 实例加载模型:

// Model
gltfLoader.load('./suzanne.glb', (gltf) =>
{
    
})

取回 gltf.scene 的第一个子对象,并将其赋给一个 wobble 变量:

gltfLoader.load('./suzanne.glb', (gltf) =>
{
    const wobble = gltf.scene.children[0]
})

像之前的 wobble Mesh 那样设置它,并将它 add() 到 scene:

gltfLoader.load('./suzanne.glb', (gltf) =>
{
    const wobble = gltf.scene.children[0]
    wobble.receiveShadow = true
    wobble.castShadow = true
    wobble.material = material
    wobble.customDepthMaterial = depthMaterial

    scene.add(wobble)
})
lesson demo video preview

它活过来了!

更进一步

Custom Shader Material 让修改内置 material 变得轻而易举,使我们能够更多关注效果本身,而不是技术细节。

欢迎你进一步探索。以下是一些建议: